前缀缓存与KV Cache复用#
一句话答案#
因果注意力下,第 i 个 token 的 K/V 只取决于它和它之前的 token,所以两个请求只要开头的 token 序列完全相同,这段前缀的 KV 就完全相同,可以算一次反复用。自部署侧 vLLM 按块哈希、SGLang 用基数树找最长公共前缀;云 API 侧的 prompt caching 是同一机制的计费化。命中只省 prefill(TTFT 和输入费用),能不能命中取决于 prompt 布局:静态内容放前面、动态内容放后面,前缀里任何一个字节变了,从那里往后全部失效。
核心要点
“Prompt Caching 能打折、要把不变的放前面”的应用层结论在 长上下文处理 和 [LLM API调用与流式输出](/topics/llm-serving/LLM API调用与流式输出) 里已经点过;本篇讲为什么能复用、引擎怎么找到可复用的块、什么会让命中失效、Agent 多轮怎么保住命中率。
1. 为什么同前缀的 KV 可以复用#
decoder-only 模型用因果掩码:位置 i 的 token 只能看到位置 ≤ i 的 token。逐层看:
- 第 1 层位置 i 的输出只由 token[0..i] 决定;
- 第 2 层位置 i 的输入是第 1 层位置 0..i 的输出,仍然只由 token[0..i] 决定;
- 归纳下去,每一层位置 i 的 K/V 都是 token[0..i] 的确定函数(位置编码也一样:同一前缀在同一位置,RoPE 旋转角度相同)。
所以请求 A = [系统提示 + 文档 + 问题1]、请求 B = [系统提示 + 文档 + 问题2],前面”系统提示 + 文档”这段的 KV 逐位相同,B 只需要从”问题2”开始做 prefill。反过来,只有前缀能复用,中间相同的一段不行:同一段文档放在不同位置、或者前面多了一个 token,它看到的上文不同,KV 就不同。
复用能省什么、不能省什么:
| 省 | 不省 |
|---|---|
| 命中部分的 prefill 计算 → TTFT 下降,长前缀时下降明显 | decode 每步仍要读全部 KV(包括命中的那段),TPOT 基本不变 |
| 并发请求共享同一份物理块 → 显存占用下降、能放下更多并发 | 输出 token 的费用 |
| 云 API 上命中部分按折扣价计输入费 | 未命中部分和第一次写入缓存的成本 |
2. vLLM Automatic Prefix Caching:按块哈希#
vLLM 的 KV 本来就按块管理(见 vLLM与高吞吐推理服务),前缀缓存是在块上加一层”内容寻址”:
- 块哈希:每个写满的块算一个哈希,
hash(父块哈希, 本块的 token ids, 额外键)。带上父块哈希,意味着哈希值代表的是”从开头到这一块为止的整段前缀”,而不只是这 16 个 token;额外键包括 LoRA adapter id、多模态输入的哈希等,保证不同 adapter / 不同图片不会误命中; - 查找:新请求进来,从第 0 块开始逐块算哈希、查全局哈希表,命中就把物理块挂到自己的块表上、引用计数 +1,遇到第一个未命中块就停,从这里开始 prefill;
- 只缓存整块:最后一个没写满的块不参与缓存,所以命中长度是
block_size的整数倍; - 释放不等于删除:请求结束后块的引用计数归零,但内容保留,进入空闲队列;真正需要新块时才按 LRU 驱逐(优先驱逐最久没用的、同等条件下优先驱逐前缀末端的块),在被驱逐前都能被后来的请求命中;
- 开关:V1 引擎(v0.11.0 起是唯一引擎)默认开启,开销很低;要关掉用
--no-enable-prefix-caching。块哈希算法默认 sha256(--prefix-caching-hash-algo可换 xxhash 等更快的非加密哈希,多租户场景官方提示有碰撞风险)。
多租户场景还要注意侧信道:命中与否会反映在 TTFT 上,理论上可以通过计时探测别人的 prompt。vLLM 支持在请求里带 cache_salt,这个值会混进第一个块的哈希,只有 salt 相同的请求才能复用彼此的 KV 块,不同租户之间互不命中。
3. SGLang RadixAttention:基数树#
SGLang 把所有缓存的 token 序列组织成一棵基数树(radix tree):边上是 token 序列,节点关联对应的 KV 块。
- 查找:新请求沿树做最长前缀匹配,匹配到的节点的 KV 直接复用;
- 插入:请求生成完,把”prompt + 输出”的新增部分作为新分支插进树,输出也能被下一轮复用——多轮对话里上一轮的回答恰好是下一轮前缀的一部分;
- 驱逐:按 LRU 从叶子节点开始驱逐,正在被请求使用的节点加锁不驱逐;
- cache-aware 调度:等待队列里按”匹配到的前缀长度”排序,共享前缀多的请求挨着跑,命中的块在被驱逐前就被用上。
| 对比 | vLLM APC | SGLang RadixAttention |
|---|---|---|
| 数据结构 | 块哈希表(链式哈希隐含前缀关系) | 显式基数树 |
| 匹配粒度 | 整块 | token 级(实现上仍受页大小影响) |
| 调度 | 默认 FCFS | 可按前缀匹配长度排序 |
| 效果 | 两者在多轮、few-shot、Agent 负载上都能大幅复用;具体差距用自己的负载压测 |
多副本时,缓存只存在于单个实例的显存里。网关如果轮询分发,同一会话的第 2 轮被打到另一台机器,就要从头 prefill。解决是前缀亲和路由:按会话 id 或前缀哈希做一致性哈希,同时参考各实例的排队长度避免热点(SGLang 的 router 等组件提供 cache-aware 路由)。
4. 云厂商 API 的 prompt caching:同一机制,计费化#
各家实现细节不同,但通用机制可以归纳为:
| 维度 | 通用规律 |
|---|---|
| 命中方式 | 按前缀匹配,从请求开头逐段比对;前缀不一致的位置之后全部不算命中 |
| 触发方式 | 两类:隐式(自动缓存,达到最小长度即可能命中,不用改代码;OpenAI、Gemini 2.5+、DeepSeek 默认如此)和显式(打缓存断点标记,如 Anthropic 的 cache_control,指定缓存到哪里为止) |
| 最小长度 | 前缀太短不缓存,门槛从几百到几千 token,各模型不同 |
| 生命周期 | 有 TTL(分钟级为主,部分支持更长),命中会刷新;长时间没有请求就过期,下次重新写入 |
| 计费 | 命中部分按折扣价计输入费;首次写入可能比普通输入更贵。以 Anthropic 为例(倍率以官方价格页为准,部分新模型的读取倍率更低):5 分钟 TTL 写入约 1.25 倍、读取约 0.1 倍,读 1 次即回本(1.25+0.1 < 2);1 小时 TTL 写入约 2 倍,要读 2 次以上才回本(读 1 次是 2.1 > 2);写入后在 TTL 内一次没被读到才是纯亏 |
| 观测 | 响应 usage 里返回命中的 token 数,字段名各家不同,见下表 |
| 范围 | 通常限定在同一组织/同一模型内(Anthropic 的 Claude API 进一步按 workspace 隔离);换模型不共享 |
各家规则(截至 2026-09,只列面试常问的维度;数字随模型更新变化,以官方文档为准):
| 厂商 | 怎么开 | 最小长度 | 生命周期 | 计费倍率(相对正常输入价) | usage 字段 |
|---|---|---|---|---|---|
| OpenAI | 默认自动缓存;GPT-5.6 起还可用 prompt_cache_breakpoint 显式打断点。prompt_cache_key 用来把相关请求路由到同一缓存、按客户/用户分开计缓存(它和 safety_identifier 一起取代旧的 user 字段) | GPT-5.6 起 1024 个可见输入 token;更早的模型随请求设置变化 | GPT-5.6 起 prompt_cache_options.ttl,目前只支持 "30m";更早的模型用 prompt_cache_retention(in_memory 约 5–10 分钟 / 24h,该参数已标废弃) | GPT-5.6 起读 0.1、写 1.25;更早的模型写入不额外收费 | Chat Completions:usage.prompt_tokens_details.cached_tokens;Responses:usage.input_tokens_details.cached_tokens、cache_write_tokens |
| Anthropic | 必须写 cache_control:放在请求顶层是自动模式(断点随对话自动后移),放在具体 content block 上是显式断点,一次最多 4 个断点;每个断点向前查找最近 20 个 block | 按模型 512–4096 token 不等 | 默认 5 分钟,可选 "ttl": "1h";命中会刷新 | 5 分钟写 1.25、1 小时写 2、读 0.1(Opus 5.5 为 0.05) | cache_read_input_tokens、cache_creation_input_tokens(input_tokens 只是最后一个断点之后未缓存的部分,三者相加才是总输入) |
| Google Gemini | 2.5 及以后隐式缓存默认开启;显式缓存要先建 cachedContents 对象再在请求里引用(只在 generateContent API 上支持) | 按模型 2048–4096 token | 显式缓存默认 TTL 1 小时,可自设 | 2.5 及以后命中打 1 折;显式缓存另按存储时长收费,隐式不收存储费 | usage_metadata.cached_content_token_count |
| DeepSeek | 硬盘缓存默认开启,不用改代码 | 官方指南未写明固定门槛 | 不再使用后通常几小时到几天内清理 | 命中价远低于未命中价,具体见官方价格页 | prompt_cache_hit_tokens、prompt_cache_miss_tokens |
Anthropic 显式缓存的前缀顺序是 tools → system → messages,工具定义排在最前面:改动工具列表会让后面整个 system 和历史全部失效。
隐式缓存下打不打标记对命中没影响;在端点会忽略该字段时,写上标记成本为零,切到显式缓存的供应商时又是必需的,所以可以统一打上。严格校验请求字段的端点可能直接拒绝未知字段,先实测再统一加。
5. Prompt 布局:什么会破坏命中#
原则一句话:越稳定的越靠前,越易变的越靠后;前缀只追加,不改写。
[工具定义(固定顺序)] → [system:角色/规则/few-shot(装配期定稿)]
→ [长文档 / 知识背景(同会话内不变)] → [历史对话(只追加)]
→ [本轮动态注入:记忆、检索结果、当前时间] → [用户本轮问题]plaintext常见破坏者:
| 破坏者 | 为什么 | 改法 |
|---|---|---|
| system prompt 里写当前时间戳 | 每次请求第一段就不同,整段失效 | 时间放到最后一条消息里,或只精确到天 |
| 工具列表顺序不稳定 | 从 dict/set 生成、按权限动态增删工具 | 固定排序;禁用工具不从列表里删,而是在执行层返回”不可用” |
| JSON 序列化键顺序变化 | 同样的内容字节不同 | json.dumps(..., sort_keys=True, ensure_ascii=False) 固定格式 |
| 随机抽样 few-shot | 每次示例不同 | 固定示例集合与顺序 |
| 用户 ID、请求 ID 放在 system 开头 | 每个用户前缀不同,跨用户无法共享 | 放到末尾,或放进 API 的 user/metadata 字段 |
| 滑动窗口删掉最早几轮 | 被删位置之后整段前移,只剩 system 能命中 | 攒到阈值一次性压缩(断一次后稳定),而不是每轮删一条 |
| 每轮重写历史摘要 | 摘要在前面,每轮内容都变 | 摘要只在触发压缩时生成一次,之后只追加 |
6. Agent 多轮场景的命中率#
Agent 的 messages 是天然的只追加结构:第 N 轮请求 = 第 N−1 轮请求 + 上一轮的 assistant/tool 消息 + 新输入。只要不改写前面,第 N 轮的前缀就是第 N−1 轮的整个请求,理论命中率随轮数上升(固定前缀占比越来越高)。
真正拉低命中率的通常是:
- 上下文压缩:一旦触发摘要或裁剪,前缀在压缩点断开。要把压缩设计成”断一次然后稳定”——直接修改状态里的消息,而不是每轮在发送前临时改视图(后者每轮都断);
- 动态内容插在前面:检索结果、长期记忆每轮都变,只能放在 system 之后;
- TTL 过期:用户思考久了、工具执行慢了,下一轮请求到达时缓存已过期;
- 并发分支:多个子 Agent 各自拼 prompt,前缀不一致。
度量用 usage 算,而且要看多次运行的分布,单次波动可能很大:
def cache_hit_ratio(usage: dict) -> float:
# OpenAI 兼容格式;Anthropic 则用 cache_read_input_tokens / 总输入
prompt = usage.get("prompt_tokens", 0)
cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
return cached / prompt if prompt else 0.0
# 回归门禁:同一批用例跑 3 遍取中位数,低于基线减容差就报警
import statistics
def cache_gate(runs: list[list[dict]], baseline: float, tol: float) -> bool:
ratios = [statistics.mean(cache_hit_ratio(u) for u in run) for run in runs]
return statistics.median(ratios) >= baseline - tolpython命中率掉下来不会报错,功能一切正常,只有账单和 TTFT 变差——所以值得放进 CI 或上线检查。上下文如何裁剪、记忆如何注入见 Agent记忆与上下文工程,成本测算见 AI应用成本优化。
面试回答(2分钟版)
前缀缓存的原理是因果注意力:位置 i 的 K/V 只依赖它和它前面的 token,所以两个请求开头完全相同的那段,KV 逐位相同,算一次就能复用。自部署时 vLLM 在分页 KV 上做按块哈希,每个满块的哈希带上父块哈希,相当于代表整段前缀,新请求逐块查表、命中就挂到自己的块表上,结束后块不立即删除,按 LRU 驱逐;SGLang 用基数树做最长前缀匹配,连上一轮的输出都能复用,还能按前缀长度调度。多副本时要做前缀亲和路由,否则轮询会把缓存打散。云 API 的 prompt caching 是同一个机制加了计费:按前缀命中、有最小长度和 TTL、命中部分打折,显式缓存首次写入可能更贵,usage 里能看到 cached tokens。命中只省 prefill,也就是 TTFT 和输入费,decode 不变。工程上关键是布局:工具定义和 system 放最前且固定,动态的记忆、检索结果、时间放后面,历史只追加不改写。常见坑是 system 里写时间戳、工具列表顺序变、按权限删工具、滑动窗口每轮删一条。对应做法是工具表每轮保持不变,禁用的工具在执行层返回提示文案而不是从列表里摘掉;每轮变化的记忆注入都放在 system 之后;命中率掉了不会报错,所以要加一个多次运行取中位数的命中率门禁。结合项目时可以讲:前缀里哪些部分固定、动态内容放在哪、命中率用什么数据盯住。
追问与易错
追问方向:
- “同一段文档放在 prompt 中间,两个请求能复用吗?” → 不能。文档前面的内容不同,文档每个 token 看到的上文不同,KV 就不同;只有从第一个 token 开始连续相同的部分能复用。
- “vLLM 为什么把父块哈希算进当前块的哈希?” → 让哈希值代表”从开头到这一块”的整段前缀,否则两段不同上文后面恰好跟着相同 16 个 token 会被误判为可复用。
- “前缀缓存会不会让输出结果不一致?” → 数学上 KV 相同,结果应一致;实际上 batch 组成和 kernel 路径不同可能带来浮点级差异,采样本身就有随机性,一般不影响语义。
- “按权限给不同用户不同的工具子集,怎么保住缓存?” → 工具定义对所有用户保持同一份、同一顺序,权限在执行层拦截并返回”无权限”的工具结果;实在需要不同工具集,就按工具集分组,每组共享一个前缀。
- “Agent 触发上下文压缩后命中率掉了,怎么处理?” → 压缩必然断一次,关键是断完之后稳定:直接修改状态中的消息(下一轮起以新前缀继续追加),攒到阈值再压一次,别每轮在发送前重新裁剪。
- “显式缓存一定省钱吗?” → 不一定。首次写入比普通输入贵,按 Anthropic 的倍率(以官方价格页为准):5 分钟 TTL 写入约 1.25 倍、读取约 0.1 倍,TTL 内读 1 次就回本;1 小时 TTL 写入约 2 倍,读 2 次以上才回本。写入后在 TTL 内没被读到、或者前缀经常变导致反复写入,就会比不开更贵;要按”写入次数 vs 读取次数”算账。
- “多租户共享一个推理集群,前缀缓存有什么安全问题?” → 命中会让 TTFT 明显变短,攻击者可以通过计时猜测别人的 prompt 前缀;用按租户的 cache salt 隔离,代价是跨租户的公共前缀不再共享。
- “怎么判断命中率的变化是真退化还是噪声?” → 云端缓存命中受路由和 TTL 影响,同一请求重复跑也会波动;用多次运行取中位数,对比基线时留容差,而不是看单次数字。
易错点:
- ❌ “前缀缓存能让生成更快” → 只省 prefill,TTFT 下降;decode 每步仍读全部 KV,出字速度基本不变。
- ❌ “打了 cache_control 标记就一定命中” → 标记只决定缓存到哪;命中与否取决于前缀字节是否一致、是否超过最小长度、是否在 TTL 内。
- ❌ “前缀缓存和语义缓存是一回事” → 前缀缓存复用的是 KV(模型仍然会生成新回答),要求 token 级完全一致;语义缓存复用的是整个回答,按向量相似度匹配,见 AI应用成本优化。